ADFA-4128: Quick Build - on-device live reload - #1669
Conversation
The pipeline in eight steps, the design contract each step honors, and the decisions that are easy to get wrong on a second read: why the daemon is a separate process, why generations only move forward, why a save must not steal the screen. - quickbuild/README.md is the entry point: terms, the eight-step pipeline, benchmark results, how to test, and the decision log. - pipeline.md walks each step in depth (it absorbed the old architecture.md). - component-proxying-design.md explains the proxy-app pattern and its limits. - resource-updates.md and concurrency.md cover the two areas where the design is least obvious from the code. - debugging.md is the triage runbook, reliability-gaps.md the honest list of known gaps with file:line citations, manual-qa.md the block-by-block device plan. - incremental-javac-design.md, ksp-kapt-feasibility.md, low-spec-devices.md and why-not-android-jar.md record the roads not taken and why. - ADR 0012 records the decision to compile outside Gradle, named to the pass and the build its numbers came from. ADR 0002 is scoped to match, since it no longer describes the only build path. Read this first. Everything below it is new modules with no callers until the app-wiring commit, so the branch is meant to be read bottom-up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE
Nothing here is Quick Build itself; it is the host-side surface the feature needs and a few fixes found on the way. - FeatureFlags gates Quick Build so it can ship dark. It tracks whether the sentinel files were actually readable rather than inferring absence from an all-false read, so a direct-boot read cannot latch the feature off. - ToolsManager stages the daemon jar and runtime AAR out of assets. - BuildService gains the hand-back hook a live session needs when an ordinary Gradle build rewrites build/ underneath it. - FlashbarActivityUtils grows a keyed debouncing action so a burst of notices cannot stack banners; Flashbar and ContentReadWrite get the small changes that supports. SaveResult carries the flags the tap's save ordering needs. - The Quick Build toolbar iconography (bolt, building, stop, error) and its strings; TooltipTag gains the Quick Build help entry. - settings.gradle.kts registers the four quickbuild modules; version catalog, publishing and module config entries for them. ARCHITECTURE.md's module map gains quickbuild. analyze.yml sets REQUIRE_BUILD_TOOLCHAIN=1 so the daemon's toolchain tests cannot skip green on a runner with no SDK. Also fixes four unrelated tests that were failing or flaky when this branch started: LogUtils, corrupt-jar classpath reading, Termux shell-manager NPE, and the debouncing-action cancel case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE
A tiny standalone module so the IDE side and the daemon side cannot disagree about the wire. Requests, results, diagnostics, and a codec, with a malformed input taxonomy that distinguishes wrong types from wrong values - the daemon reads whatever a broken client sends, so parsing has to fail precisely rather than throw. The types live in their own package rather than the module root, so a reader can tell wire format from transport at a glance and neither side can widen the contract by accident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE
The session state machine and everything it drives: watching for edits, classifying them, deciding a build route, running it, and deploying the result. Laid out package-per-concern (domain/ split from service/), each package with a README stating its contract. - One build in flight; a newer edit supersedes an older one; generations only move forward. - Change classification decides the cheapest route that is still correct - annotation-aware, because a Room @query edit cannot take the fast path. - Trailing-debounce coalescing so a save burst is one build, with late echo batches absorbed rather than stranded. - Deploy policy chooses reload versus component restart; the deployer owns relaunch, reconnect, and the retry when no app is connected. A forced no-op deploy still ships every asset, and a retained payload is re-sent rather than rebuilt. - Daemon lifecycle with an epoch protocol, so a respawn cannot be adopted by the session that asked for the previous one, and a replaced daemon cannot report its own death as the live one's. - A tap carries one bit: it supersedes the build it replaces and is consumed, so it neither forces a blind rebuild nor gets swallowed by a parked session. Correctness is what picks the route, not speed, and one case where the two disagree is worth naming here because it looks like a missing optimization. The proxy app runtime serves deployed assets through a ResourcesLoader AssetsProvider, which exists only on API 30+. Below that the runtime still extracts an asset payload but nothing reads it, so an asset edit acked "reloaded" would leave the app on stale assets - a silent never-stale violation. ChangeClassifier therefore takes assetsLiveReloadable, and when it is false any changed set carrying an asset routes to a full Gradle build. The gate is on the flag rather than on the AssetsOnly arm because changed assets ride in every route's deploy payload, so a code+asset edit would otherwise still ship assets nothing serves. Resources are deliberately untouched: 28/29 have their own LegacyResourceSwap path. The value is threaded down from the Android edge (see the app-wiring commit) rather than read here, keeping SDK routing out of the pure classes. The route reports UNSUPPORTED_FILE_CHANGED, whose meaning already covers a watched file the live reload path cannot deliver. Timing is charged to the save that earned it, not to a dead attempt's, and a save that arrives while the compiler is down is answered and narrated rather than dropped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE
A long-lived JVM that keeps kotlinc's incremental caches warm between edits. This is where the speedup comes from: a cold compiler per edit is most of what makes an ordinary on-device build slow. - Incremental Kotlin and Java compilation, with the ABI fingerprint that decides whether a .java edit forces a Kotlin recompile. The fingerprint hashes the file's imports, since an import change is an ABI change downstream. - d8 dexing, aapt2 resource linking, and toolchain discovery over the messy SDK layouts real devices actually have. The aapt2 subprocess is bounded by a timeout that kills it, so a hung linker cannot wedge a session. - final-stripping so a user class can be subclassed by a generated proxy. - A changed-class set the deploy policy can trust, and a split payload that fails rather than deploying half of itself. - An exception backstop on every op: the daemon exits on shutdown, EOF, or a fatal internal error, and never on a handler throwing. A compiler Error is answered as a failed build rather than taken as fatal. - A session releases its tools only once its replacement exists, so a restart cannot leave a window with no compiler. - An offline guard that fails the build if any production class in the module references a network API. The aapt2/d8/Compose regression tests (ADFA-4128 bugs 5/6/8) are assumption-guarded through TestSdk, so on a runner without an Android SDK they would skip green and take that coverage with them. The analyze workflow sets REQUIRE_BUILD_TOOLCHAIN=1 to turn an absent toolchain into a hard failure instead. The runner does have an SDK - Assemble V8 Debug above could not run otherwise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE
Quick Build cannot swap a class the manifest names directly, so the plugin generates Proxy<N><Type> subclasses and rewrites the manifest to name those instead. The manifest then points at something stable while the code behind it changes. - Decides which components can be proxied at all, and why each rejection happens. - Walks a user class up to its framework supertype to pick the right proxy shape. - Synthesizes an activity-alias under each real activity class name so in-app navigation by explicit class keeps resolving. - Emits quickbuild.json, the contract the device side reads back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE
The half that lives inside the proxy app. Receives a payload over a binder channel and swaps code, resources, and assets into the running process. - Classloader routing so freshly compiled classes win over the ones the app started with. - Resource swap with three strategies by API level: ResourcesLoader on 30+, a reflective shim on 28/29, unsupported below. - Payload persistence that survives the crash window between writing bytes and recording the generation they belong to. A deploy is all-or-nothing on disk, and a payload that fails to apply is quarantined rather than left to be read back as good. - The app boots at a monotonic stamped baseline generation, so a restart cannot resurrect a payload older than the build the app was made from. - Reload confirmation is render-proof when an activity is resumed; when the app is backgrounded the runtime acks at apply time instead, so a plain save never times out and never brings the app forward. - A keep-alive service holds the proxy app out of Android's cached-app freezer, so a deploy to a backgrounded app is not silently stalled by the platform. - A build-failure overlay while the app is foreground; a hand-rolled JSON parser rather than a dependency, since this AAR ships inside the user's app. The parser reports its own failure rather than the fallback's. - Service-connect hardening: a RuntimeException while binding is caught like a dead binder, and the reconnect backoff only resets after a successful connect. Assets ride the same loader as the resource table. They shipped end-to-end but were never served: the overlay was built with a null AssetsProvider and the only accessor (overrideAsset) had no callers, so modified assets silently served stale content and new assets crashed on read. The extracted assets now go through a DirectoryAssetsProvider (open-coded; the framework's is not public API) over one cumulative override dir, wrapped in ResourcesProvider.empty for assets-only payloads and installed alongside the table provider on API 30+. Payloads carry only changed assets, so extraction merges into the cumulative dir instead of per-generation dirs, keyed to the baseline fingerprint and cleared on mismatch - the same trigger that discards a persisted payload - so assets never outlive their baseline. Two limits documented rather than fixed. The overlay can add and replace but cannot hide, so a deleted asset stays readable until the next proxy app rebuild. And below API 30 there is no loader to hang the provider on at all, which is why the classifier routes asset-bearing edits to a full Gradle build there instead of deploying assets nothing would read - never stale, at full-build cost. The README limitations row and pipeline.md say both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE
The IDE-side integration: a Quick Build action next to Run, session lifecycle tied to the editor, provisioning the proxy app on first use, and narration of every stage where the user already looks. - QuickBuildManager owns the session; provisioning runs the proxy-app build through the normal Gradle path and reports progress, and a stop tap cancels it rather than leaving it orphaned. - Build stages narrate into the Build Output pane and the bottom status bar, same as an ordinary Gradle build; failures show BUILD FAILED there, and the next landed build overwrites it. A delivery failure is narrated as a delivery failure, not a build failure, and a parked rebaseline as the failure it is. - Stale standard-build error banners no longer replay on lifecycle re-collect (the error state is consumed after first display). - The status bar reports "live reloaded in X ms" rather than a generation number - generations are internal bookkeeping; developers only care that the reload landed and how fast (Bryan, QA walk). - A build-type switch is confirmed when the app id is unknown, and the switch is held until the rebaseline it depends on lands. - The save-time generateSources is deferred until Quick Build goes idle and fires only for resource XML, and the eager prebuild is staggered off the project-open spike, so neither competes with the edit the user just made. - The internal-build bracket is released on every exit path. - Kaspresso e2e coverage: pipeline and smoke tests, a flag-off test, plus automation helpers. Narration survives backgrounding. Narrating from a collector inside the editor activity's repeatOnLifecycle(STARTED) loses builds twice over: a build the user backgrounded CoGo to watch narrated into a cancelled collector, and the status StateFlow's replay on return arrived as a first emission, which quickBuildOutputLines rightly says nothing about. Manual QA saw the newest generation's timing and nothing before it. The two collectors are split: the status bar stays lifecycle-scoped - it shows state, not history, so a cancelled collector costs it nothing - while narration moves to QuickBuildOutputNarrator, attached to the session manager's status for as long as the session exists. The editor activity now only binds the pane, and lines produced while none is bound (backgrounded, or between two activities) queue in the narrator until one is. Each landed build's stage timings also route into the pane. They ride the metrics port rather than the session status - E2eTimeline is the only type carrying the per-stage split - so QuickBuildOutputMetricsSink forwards them and quickBuildTimingLine renders the stages that actually ran: "Quick Build: generation 5 - compiled in 2.8s, dexed in 0.4s, relinked in 2.3s (total 6.0s)." The Koin module is also where the classifier's assetsLiveReloadable flag is read - Build.VERSION.SDK_INT >= R, evaluated once at the Android edge and threaded down, so nothing in quickbuild:core has to know about SDK levels. The Quick Build help row is a documentation.db hand-off rather than a host-side write: the app declares the tooltip tag and the invocation site, and the row itself is the documentation asset's to ship. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE
…at they say The debug-only benchmark surface: an adb-triggerable trampoline, the event and metrics recorders behind it, and the comparator fix that decides what the published speedup is a speedup over. The standard-build number stopped early. `standard_build_finished` fires on BuildState.AwaitingInstall, so every published comparison had counted a Gradle build with no APK install and no app launch, against a Quick Build number that includes its full deploy and reload. The comparison was biased against Quick Build; the interesting part is that nothing said so. MODE_STANDARD_E2E runs the same Run action and keeps measuring: it installs, launches, and stamps `standard_install_started`, `standard_install_finished` and `standard_e2e_finished` (carrying tapToRunningMs from the tap instant). The span starts where MODE_STANDARD's does - the timestamp is taken on the line before `runQuickBuild`, which is the call the toolbar Run action makes - so the two modes share a start and differ only in where they stop. A separate mode rather than a flag, because folding install into MODE_STANDARD would silently redefine `standard_build_finished`, and a number whose meaning changed under a name that did not is how two passes get compared as if they measured the same thing. Two dialogs sit in that path and neither can be answered by an unattended device, so a bench run takes the no-dialog route: the Quick-Build-clobber confirmation is bypassed (the session is restarted directly, which is what confirming does), and the launch skips `launchAppAfterInstall` and its prompt by launching from an override of `onInstallationResult` instead of reaching super. The human path is untouched - both bypasses are gated on the e2e latch. The system installer's own confirmation is not ours to suppress; it needs the REQUEST_INSTALL_PACKAGES appop pre-granted, which the harness already does. The span ends when startActivity returns, which is the last instant this process can observe; first frame is a further wait only the framework sees, so the number UNDERSTATES tap-to-usable. Said in the code so nobody reads the field as more than it is. Failure paths stamp too - a build that never reaches an installable APK, an install that reports no package, and a failed launch each end the span with a reason, so a missing measurement is a labelled terminal state rather than an absent row, and the latch is released, which is what stops the next run's collector attributing a human's install to the bench. The whole surface is debug-only and cannot be reached in a release build: the trampoline activity is declared in a debug-source-set manifest, and QuickBuildBenchHooks has an inert release twin so the main sources can call it unconditionally. The activity is exported by necessity - adb shell holds no START_ANY_ACTIVITY, so a non-exported activity could not be driven at all - and is therefore gated on android.permission.DUMP, which adb shell holds, root bypasses, and no third-party app can obtain. Without that gate the feature flags were the only protection, and those are files in the public Downloads directory that any app with storage access can create. The MODE_STANDARD_E2E path is host-compiled only, not yet verified on a device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTUVfxid5riL2T78piiKDE
59396fa to
768b8b1
Compare
|
What does "every core port" mean? |
|
In the first table, the row "app wiring" says "The only unit touching existing code — the IDE integration: the toolbar button, the Koin module binding every core port to Android, provisioning, the Gradle heap strategies, AndroidManifest.xml." What does "core port" mean? Is it about networking? |
|
Good catch — that was jargon, and not even the useful kind. It's nothing to do with networking. I've reworded the row to say what it actually means:
The reason it's built that way: core never names an Android class, so its logic is unit-testable on the JVM. The |
…ue behaviour Both claims were measured false on an A56 (Android 16) while investigating the reload timing, and both would mislead a reviewer about a timing property the code does not have. - :263 said the OS defers the recreate until the next resume. It does not. The tracker still holds a stopped-but-not-destroyed activity, so recreate() schedules the relaunch immediately -- before this ack is written. The ack-at-apply is still correct, but for a different reason: there is no resumed activity to hang a frame callback on, and waiting would time out a deploy that worked. Written as what the code does rather than what the OS guarantees, since eager relaunch is plausibly version- and state-dependent and only one device was measured. - :308 said a resumed frame is the first proof the new generation rendered. onResume is not a rendered frame; it precedes the first draw, so the reported time understates time-to-pixels by that margin (~4 ms measured, foreground path). Comments only -- no behaviour change. Verified comments-only by diff, and compiled: :quickbuild:runtime:compileV8DebugJavaWithJavac BUILD SUCCESSFUL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EMAzZxcPSpxqbYdTxHaZhx
Quick Build lets the user run a live reload loop that updates their already running test app in seconds -- instead of going through an incremental Gradle build and reinstall. This opens as a draft so the team can start playing with it now; the remaining known fixes land on this PR as batched commits rather than followup PRs.
1. Overview
Please read the
quickbuild/README.mdfirst -- it's a good overview map for understanding the rest of this PR -- how the feature works, key components, decisions, and limitations.It also links to more detailed docs in the
docs/folder wherepipeline.md(more detailed notes + diagrams per component) andconcurrency.mdmight be useful reading.Refactoring is not too hard -- so if the PR would benefit from major changes, please ask!
2. Review by Commit
This PR is organized by commit. It's probably easiest to review by commit, and I'll stop rewriting history now that the PR is open and any fixes will be batched into future commits.
common,logger,resources, settings.DaemonProtocol.kt.LiveReloadOrchestrator.kt,ChangeClassifier.kt,SessionReducer.kt.DaemonMain.kt,IncrementalCompiler.kt.QuickBuildPlugin.kt,ProxySourceGenerator.kt,QuickBuildManifestTransformer.kt— manifest rewriting hides subtle breaks.QuickBuildRuntime.java,QuickBuildAppComponentFactory.java,PayloadStore.java.AndroidManifest.xml. Start fromQuickBuildAction.kt,QuickBuildModule.kt,GradleQuickBuildProvisioner.kt.3. How this was tested
quickbuild/docs/manual-qa.mdfor the test plan.So far we've run Block A + B and currently there are a few minor bugs left to fix, but it mostly works!See these two Loom videos::resources.4. Known limitations and next steps
This highlights some of the more important next steps and known limitations. Please see the limitations section of
quickbuild/README.mdfor a more complete list.Next steps to get Quick Build done
Existing tickets:
Known Limitations
android:processattribute yet🤖 Generated with Claude Code, edited heavily by Bryan
https://claude.ai/code/session_01CsRt7FJyQtTkkJoCcEURA9